fix(chat): never leave a client tool call unanswered on the server - #807
Conversation
Design for five defects found reviewing the shipped client-tool continuation stack (#782-#805). Four violate one unstated invariant: the server thread must never hold a client tool call without a result. Adds flush() to ClientToolsCapability so a settled result can be made durable without continuing the run, and maps all five fixes onto it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten tasks covering all five defects, TDD per task, with a live browser verification gate whose decisive step is reload-then-continue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aging on thread switch
Flushing per blocked call stranded every batch after the first: adapter flush() implementations coalesce concurrent calls by returning the in-flight promise, so only the first batch was ever snapshotted. Gate the blocked-group flush on group completion, mirroring the terminal path. Drop the blockedIds guard: both shipped adapters mark a call resolved inside settle() so pending() drops it immediately, meaning a settled call can never be re-presented to the executor effect. The hazard it guarded was an artifact of a test double whose settle() left calls pending forever; the fakes now mirror adapter behavior instead. Warn rather than silently discard when a blocked call cannot be recorded because the capability implements no settle().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Update — three blocking issues from final cross-batch review, fixed in
|
…act (#808) Client-tools guide gains app-facing docs for followUp:false terminal tools, idempotent + the execution guard, AbortSignal on handlers, and the continuation-turn cap — none of which were documented after #807. Adapter guide gains the settle/flush/resolve contract, the invariant it protects, and the three implementation rules each learned from a real defect (batch ownership at snapshot, chain don't short-circuit, discard staged results on thread switch). Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
Fixes five defects found reviewing the shipped client-tool continuation stack (#782–#805). Four violate one invariant the architecture depended on but never stated:
When violated, the thread holds an
AIMessage(tool_calls=[…])with noToolMessage, and most providers reject that history with a 400 on the next user turn.The central change
ClientToolsCapabilitygainsflush(), making the three settlement verbs distinct:settle(id, result)flush()resolve(id, result)AG-UI's
flush()is a no-op — itssettle()already callsaddMessage. LangGraph's batches the buffer into onethreads.updateStatewrite (noasNode, soadd_messagesappends and the resume point is untouched), falling back to draining into the nextsubmit()when the transport has noupdateState.The five defects
followUp: falsecorrupted LangGraph threads. When every tool in a group was terminal,resolve()was never called — and it was the only thing that drained the buffer. Results never reached the server. AG-UI was immune, which is what made the asymmetry easy to miss.pending()and was re-dispatched after the next run — a second execution of a side-effecting handler the user explicitly stopped.asktool's user-supplied answer was thrown away and the calls left unsettled. Now real results are preserved and only never-executed tools get a limit error.agent.stopwrappers stacked without bound. Now wrapped once per agent via a registry and restored on destroy.tenant_idwas written but never filtered on. NowNOT NULL DEFAULT '', in the primary key and everyWHERE/conflict target.Behavior changes worth calling out
Both are required to hold the invariant, but both are observable.
Schema change
threadplane_client_tool_executionsgainstenant_idin its primary key. No migration tooling — the table is recreated. Authorized explicitly; no backward compatibility required.Verification
Live, against a real LLM and a real LangGraph backend — the decisive test, because the failure is a provider rejection of persisted server state that no unit test can reproduce:
show_trip_summarytool → no follow-up run fired, andGET /threads/{id}/stateshowed theToolMessagepresent. Unanswered tool calls: none.Automated: 1102 passing in
chat, 294 inlanggraph, 206 inag-ui, 46 inmiddleware, 103 inexamples-chat-angular. Lint errors: 0 across all five. API docs regenerated for the new public surface (flush,settleWithoutContinuing,cancelledClientToolResult).Review notes
Each batch was adversarially reviewed, and the reviews caught four defects that the plan and the implementations both missed — worth knowing since three came from following the plan literally:
flush()originally left its batch in the buffer across theawait, but two other mutators clear that buffer unconditionally — it could destroy an unpersisted result and double-send a persisted one.cap.resolve) meant Stop submitted a run. Fixed with a settle-only channel that can never fall back toresolve.Known follow-up (not fixed here)
When a custom transport lacks
updateState,persistFnis undefined andflush()resolves with no diagnostic — the coordinator can't distinguish that from a real write. The submit-drain fallback still covers it, so the residual exposure is a reload before the user's next message. Closing it properly needs a signal on the capability contract.Spec:
docs/superpowers/specs/2026-08-07-client-tool-continuation-fixes-design.mdPlan:
docs/superpowers/plans/2026-08-07-client-tool-continuation-fixes.md🤖 Generated with Claude Code